Concurrency in Java
Concurrency is one of the most important and tricky topics in Java. It allows programs to perform multiple tasks at the same time, but introduces problems like race conditions, visibility issues, and deadlocks if not handled carefully.
1. Processes, Threads, and the JVM
- Process = an instance of a running program.
- Thread = a lightweight unit of execution inside a process.
- Every Java program starts with the main thread.
- JVM also runs background threads (like garbage collector).
👉 To create new threads in Java:
Thread t = new Thread(() -> {
System.out.println("Hello from a new thread!");
});
t.start();
2. Thread Lifecycle and APIs
Important methods:
start()
→ begins execution in a new thread.sleep(ms)
→ pauses current thread.join()
→ waits for another thread to finish.interrupt()
+isInterrupted()
→ cooperative thread termination.setDaemon(true)
→ makes a background thread (JVM won’t wait for it).
Example:
Thread worker = new Thread(() -> {
try {
Thread.sleep(3000);
System.out.println("Work done!");
} catch (InterruptedException e) {
System.out.println("Interrupted!");
}
});
// Non-daemon (JVM waits)
worker.start();
// Main waits explicitly
worker.join();
3. JVM Exit Behavior
- JVM keeps running if there’s any non-daemon thread alive.
- JVM can exit immediately if only daemon threads remain.
- Use
join()
when you want explicit waiting.
Feature | Non-Daemon (default) | Daemon |
---|---|---|
JVM waits? | ✅ Yes | ❌ No |
Example | Worker threads | Logging, GC |
4. Concurrency Problems
4.1 Race Conditions
A race condition happens when multiple threads access and modify shared data simultaneously.
Example:
class Counter {
int count = 0;
public void increment() { count++; }
}
If multiple threads call increment()
, final result is unpredictable.
4.2 Fixing Race Conditions
Techniques:
- Confinement → keep data thread-local.
- Synchronization → locks,
synchronized
. - Atomic classes →
AtomicInteger
,AtomicBoolean
, etc. - Immutable objects → safe by design.
5. Locks and Synchronization
5.1 Using synchronized
class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
}
5.2 Using Explicit Locks
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
class Counter {
private int count = 0;
private Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
try { count++; }
finally { lock.unlock(); }
}
}
6. Volatile and Visibility
Threads can cache variables locally, leading to visibility issues.
- Without
volatile
, one thread may not see another’s changes. - With
volatile
, all reads/writes go to main memory.
Example:
class Flag {
private volatile boolean done = false;
public void work() {
while (!done) { } // busy wait
}
public void stop() {
done = true;
}
}
⚠️ Note: volatile
ensures visibility, but not atomicity.
x++
is still unsafe, even if x
is volatile.
7. Atomic Classes
Java provides classes in java.util.concurrent.atomic
that ensure atomic updates:
import java.util.concurrent.atomic.AtomicInteger;
class AtomicCounter {
private AtomicInteger count = new AtomicInteger();
public void increment() {
count.incrementAndGet();
}
public int get() {
return count.get();
}
}
✔ Uses CPU-level instructions (Compare-And-Swap
) to guarantee atomicity without locks.
8. Thread Communication: wait()
and notify()
Sometimes threads need to communicate.
wait()
→ makes a thread pause until notified.notify()
→ wakes one waiting thread.notifyAll()
→ wakes all waiting threads.
⚠️ Must be used inside a synchronized
block.
Example:
class DownloadStatus {
private boolean done = false;
public synchronized void waitUntilDone() throws InterruptedException {
while (!done) {
wait(); // releases lock, waits
}
}
public synchronized void markDone() {
done = true;
notifyAll(); // wakes waiting threads
}
}
9. Collections in Concurrency
Collections.synchronizedList()
→ wraps a list with locks.ConcurrentHashMap
,CopyOnWriteArrayList
→ designed for concurrency, faster than synchronized wrappers.
Example:
var list = Collections.synchronizedList(new ArrayList<>());
list.add(1);
10. Executor Framework (Modern Approach)
Instead of managing threads manually:
import java.util.concurrent.*;
ExecutorService executor = Executors.newFixedThreadPool(4);
executor.submit(() -> {
System.out.println("Task running");
});
executor.shutdown();
✅ Key Takeaways
Thread basics:
start()
,sleep()
,join()
,interrupt()
.Daemon vs non-daemon: JVM exit depends on them.
Concurrency problems: Race conditions, visibility.
Solutions:
- Confinement
- Synchronization
- Locks
- Atomic classes
- Immutability
Visibility problem solved by
volatile
.Thread coordination:
wait()
/notify()
.Use concurrent collections instead of manual sync.
For real apps → prefer Executors over manual
Thread
.
ExecutorService
Introduction
Working with threads directly in Java is difficult and error-prone. Java 5 introduced the Executor Framework to abstract away the complexity of thread management. This lesson covers:
- Thread pools and ExecutorService
- Callable and Future interfaces
- Asynchronous programming with CompletableFuture
- Modern patterns for concurrent programming
Thread Pools and ExecutorService
The Problem with Direct Thread Usage
Creating threads directly has two major issues:
- Cost: Creating and destroying threads is expensive
- Availability: System resources are limited
Solution: Thread Pools
A thread pool is a collection of worker threads. When a worker thread finishes its task, it returns to the pool to execute other tasks instead of being destroyed.
ExecutorService Basics
public class ExecutorDemo {
public static void main(String[] args) {
// Basic executor usage
show();
// Non-blocking example
var exec = show2();
System.out.println("this runs immediately");
exec.shutdown();
// Working with return values
var result = show3();
try {
var value = result.get();
System.out.println(value);
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e);
}
}
// Blocking example with try-with-resources
public static void show() {
try (var pool = Executors.newFixedThreadPool(2)) {
pool.submit(() -> {
delay();
System.out.println(Thread.currentThread().getName());
});
System.out.println("hello");
} // pool.close() blocks until all tasks complete
}
// Non-blocking example
public static ExecutorService show2() {
var pool = Executors.newFixedThreadPool(2);
pool.submit(() -> {
delay();
System.out.println(Thread.currentThread().getName());
});
System.out.println("hello"); // Prints immediately
return pool; // Must manually shutdown later
}
// Returning values with Future
public static Future<Integer> show3() {
try (var pool = Executors.newFixedThreadPool(2)) {
return pool.submit(() -> {
delay();
return 25;
});
}
}
public static void delay() {
try {
Thread.sleep(Duration.ofSeconds(2));
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
Key ExecutorService Implementations
- ThreadPoolExecutor: General-purpose thread pool
- ScheduledThreadPoolExecutor: For scheduled tasks
- ForkJoinPool: For divide-and-conquer algorithms
Important Note About try-with-resources
When using try-with-resources
with ExecutorService:
- The
close()
method callsshutdown()
and blocks until all submitted tasks finish - This can make your code appear synchronous even though tasks run on separate threads
- For truly non-blocking behavior, manage the executor lifecycle manually
Callable and Future Interfaces
Moving Beyond Runnable
While Runnable
is great for fire-and-forget tasks, often you need to:
- Return a value from your task
- Handle exceptions properly
- Check if the task is complete
Callable Interface
public class ThreadPoolExample {
public static void main(String[] args) {
try (var exec = Executors.newFixedThreadPool(2)) {
// Submit a Callable that returns a value
var result = exec.submit(() -> {
System.out.println(Thread.currentThread().getName());
LongTask.simulate();
return 1; // Return value
});
try {
// get() is a BLOCKING call
var res = result.get();
System.out.println(res);
} catch (InterruptedException | ExecutionException e) {
throw new RuntimeException(e);
}
}
}
}